View 事件分发看了那么多还是不懂?这回让你一次明白!
码个蛋(codeegg)第 702 次推文
作者:KunMinX
链接:https://juejin.im/post/5d3140c951882565dd5a66ef
我无法忘却 3 年前备受折磨的那个夜晚 —— 在我第一次学习 View 事件分发,却被网文折磨的那个夜晚。
是网上介绍 View 事件分发的文章不够多吗?
不是的,恰恰相反,网上的爆款文章不计其数,待你仔细阅读,却 颇有一种“外地人上了黑车”的感觉—— 一言不合先上 30 张图表,带你在城市外围饶个上百圈,就是不直奔主题 解释一个现象为什么会存在、造成它存在的缘由为何、它如此设计是为了解决什么问题 ……
比起 拨开迷雾、明确状况、建立感性认识,他们更热衷于自我包装。
—— 有没有帮助我不管,先唬住人再说。
为了唬人,就算给他人徒添困扰、白费大量时间,也在所不惜!
正是对那次痛苦经历的念念不忘,于是我 破例将这篇文章分享给大家。
在此,我向 3 年前的那个自己发誓,我必在 结尾 200 字就讲明白,别人非要绕个 3000、5000 字都讲不明白的事件分发。
不仅如此,我还要额外地帮助大家理解,事件分发流程中的 3 个小细节:之所以如此设计,是出于什么考虑。通过“知其所以然”,来方便大家更好地加深印象。
😉
今天讲的是基础,光是看了这一篇,你也没白来!
1. View 事件分发的本质是递归
什么是递归呢?递归的本质是什么呢?
顾名思义,递归是一种包含 “递” 流程和 “归” 流程的算法。当我们在找寻目标时,便是处于 “递” 流程,当我们找到目标,打算从目标开始来执行事务时,我们便开启了 “归” 流程。
如果这么说有点抽象的话,不妨结合现实中的实例来理解下递归:
案例:职场任务的下发和上报,就是典型的递归
领导 自上而下、逐级地下达任务、寻找目标执行者,这就是 “递” 流程。
直到找到合适的执行者时,便开启了 自下而上的 “归”流程。若当前执行者无法让结果 OK,那么上报给他的上级,由他的上级来执行,如果上级也不 OK,那么继续向上,直到结果 OK 为止。
伪代码来表示,即:
boolean dispatch(){
if (hasTargetChild) {
return child.dispatch();
} else {
return executeByMySelf();
}
}
2. 为何要设计成递归呢?
如此设计,是为了与 View 的排版相呼应。
View 的排版规则是:嵌套越深的,显示层级越高。而显示层级越高,就越容易覆盖层级低的、被用户看见。
再加上,“所见即所得”,要求 “用户看到了什么,触控到的也该是什么”(简言之,操作要符合用户直觉)。
因此,正是考虑到嵌套越深,层级越高,触摸也通常会是交给层级高的来处理,因而也将事件分发设计成递归。
排版规则为何“嵌套越深,显示层级越高”?
因为这符合常理。越外层的,作为父容器而充当背景,越里层的,作为子控件而至于前景。
<LinearLayout>
<ScrollView>
<TextView/>
</ScrollView>
</LinearLayout>
3. 整个流程
首先我们要明确的 3 点是:
1.每次完整的事件分发流程,都包含自上而下的 “递”,和自下而上的 “归” 2 个流程。
2.每次完整的事件分发流程,都是针对一个事件(MotionEvent)完成的递归,而一个事件只对应着一个 ACTION,例如 ACTION_DOWN。
3.一次用户触摸操作,我们称之为一个事件序列。一个事件序列会包含 ACTION_DOWN、ACTION_MOVE ... ACTION_MOVE、ACTION_UP 等多个事件。(其中 ACTION_MOVE 的数量是从 0 到多个不等)
也即一个事件序列,包含从 ACTION_DOWN 到 ACTION_UP 的多次事件分发流程。
下面我用一张图概括 View 事件分发的递和归流程。
如图所示:👆👆👆
事先分发包含 3 个重要方法:
dispatchTouchEvent,onInterceptTouchEvent,onTouchEvent。
View 和 ViewGroup 是组合模式的关系,因而 ViewGroup 为了分发的需要,会重写一些 View 的方法,就包括这里的 dispatchTouchEvent。
因而首先,在递的过程中,当前层级是执行 child.dispatchTouchEvent:
如果 child 是 ViewGroup,那么实际执行的就是 ViewGroup 重写的 dispatchTouchEvent 方法。该方法内可以判断,是否在当前层级拦截当前事件、或是递给下一级。
如果 child 是不再有 child 的 View 或 ViewGroup,那么实际执行的就是 View 类实现的 super.dispatchTouchEvent 方法。该方法内可以判断,如果 View enabled 并且实现了 onTouchListener,且 onTouch 返回 true,那么不执行 onTouchEvent,并直接返回结果。否则执行 onTouchEvent。
此外,在 onTouchEvent 中如果 clickable 并且实现了 onClickListener 或 onLongClickListener,那么会执行 onClick 或 onLongClick。
总之,走到没有 child 的层级,即意味着步入“归”流程,如果该层级的 super.dispatchTouchEvent 没有返回 true,那么将继续执行上一级的 super.dispatchTouchEvent,直到被某一级消费,也即返回 true 了为止。
上面我们介绍了正常流程下,所会执行到的方法,包括 View 实现的 dispatchTouchEvent,ViewGroup 重写的 dispatchTouchEvent,以及 onTouchEvent。
如图。👆👆👆
其实在事件的 “递” 流程中,ViewGroup 可以在当前层级,通过设置 onInterceptTouchEvent 方法返回 true,来拦截事件的下发,而直接步入“归”流程。
正所谓 “上有正策、下有对策”。在 ViewGroup 可以拦截事件下发的同时,child 也可以通过 getParent.requestDisallowInterceptTouchEvent 方法,来阻止上一级的下发拦截。
4. 额外的三个小细节
细节1:明确消费的概念
要将 “消费” 和 “执行” 这两个概念明确区分开。
网上的内容总让人误以为,当前层级不消费,就是不执行 super.dispatchTouchEvent 了。
事实上,不消费,简单地理解就是,“事情做了、只是结果不 OK” —— 在归流程中,如果当前层级的 super.dispatchTouchEvent return true 了,那么再往上的层级都不再执行自己的 super.dispatchTouchEvent,而是直接 return true。并且,当前层级的下级,都执行过 super.dispatchTouchEvent,只是结果返回了 false 而已。
细节2:明确拦截的作用
网上的内容总是让人误以为,当前层级拦截了,就直接在当前层级消费了。
实际上,当前层级拦截了,只是提前结束了 “递” 流程,并从当前层级步入 “归” 流程而已。具体判定是在哪个层级被消费,还是根据 <细节1> 的指标:看在哪个层级的 super.dispatchTouchEvent return true。
细节3:拦截方法只走一次,不代表拦截只走一次
网上的内容总是让人误以为,本次 ACTION_DOWN 被拦截了,那么往后的 ACTION_MOVE 和 ACTION_UP 都不被拦截了。
实际上,是 onInterceptTouchEvent 方法只走一次,一旦走过,就会留下记号(mFirstTouchTarget == null)那么下一次直接根据这个记号来判断拦不拦截。
为什么这么设计呢?因为一连串的事件序列,要求在几百微秒内完成。如果每次都完整走一遍方法,那岂不耽误事?所以本着 “能省即省” 的原则,凡是已确认会拦截的,后续就不再走方法判断,而是直接走变量标记来判断。
到此已经讲完 3 个细节了,要不要再讲 2 个呢?
讲?不讲?讲?不讲?
好嘛,再讲 2 个 ~
细节4:ACTION_DOWN 不执行,那么没下次了
这个很好理解,和 <细节3> 同理。
连事件序列的第一个事件都不接了(父容器走后续事件的分发时发现 mFirstTouchTarget == null),那就意味着不接了呗 —— 那后续的活就不会交给你了(不会再走你的 super.dispatchTouchEvent 来试探),直接根据变量标记(mFirstTouchTarget == null)做出判断,“能省即省”。
细节5:内部拦截并不能阻止父容器对 ACTION_DOWN 的处理
也即在 child 的 onTouch、onTouchEvent 中调用 getParent.requestDisallowInterceptTouchEvent 时,被设计为对父容器的 ACTION_DOWN 无效 —— 在父容器 dispatchTouchEvent 时,会首先重置 mGroupFlags。( ViewGroup 正是根据 mGroupFlags 是否包含 FLAG_DISALLOW_INTERCEPT 来判断是否不拦截的)
为什么这么设计呢?
这个问题读者可以想一想,欢迎在评论区留言 ~
5. 总结
View 事件分发的本质是递归。
递归的本质是,任务的下发和结果的上报。
View 事件分发设计成递归,是为了配合 View 的排版规则,形成符合用户直觉的触控体验。
View 事件分发的对象是一个 MotionEvent。
一次用户触控操作包含多个 MotionEvent(例如从 ACTION_DOWN 到 ACTION_UP ),也即会走多次事件分发流程。
一次 View 事件分发流程包含 “递” 流程和 “归” 流程,“递” 流程可以因 ViewGroup 的拦截而提前步入 “归” 流程。
child 可以通过 getParent.requestDisallowInterceptTouchEvent 阻止父容器的拦截。因而需要差异化地配置阈值,来确保 child 执行 getParent.requestDisallowInterceptTouchEvent 优先于父容器 onInterceptTouchEvent 返回 true(不然都先被拦截了,child 哪有机会阻止?)
在“归”流程中,唯有当前层级的 super.dispatchTouchEvent 返回了 true,才认定被消费,被消费前,下级都有干活,只是结果不 OK。被消费后,上级都不需要干活,直接向上传达消费者的功。
这样说,你理解了吗?
近期文章:
今日问题:
你理解了嘛?
快来码仔社群解锁新姿势吧!社群升级:Max你的学习效率